feat(font): replace generated font catalog with Vite import-rewriting transform#262
feat(font): replace generated font catalog with Vite import-rewriting transform#262yunus25jmi1 wants to merge 4 commits intocloudflare:mainfrom
Conversation
commit: |
|
@southpolesteve @threepointone @elithrar Kindly review the changes. The workflow is completely green. All checks are passed with 12 successful checks & 3 skipped. |
|
/bigbonk review |
There was a problem hiding this comment.
Review: feat(font): replace generated font catalog with Vite import-rewriting transform
The core idea is great — replacing a 1928-line generated catalog with a compile-time import rewrite is a clear win for maintenance and bundle size. The import-rewriting logic is well-structured and handles edge cases like type imports, as aliases, and utility re-exports.
However, there are several issues that need to be addressed before merging.
Critical Issues
-
Build-mode self-hosting runs regex on the original code, but MagicString has already rewritten the import. The
fontCallReregex runs on the originalcodestring (line 2978), buts.overwrite()has already replaced the import region. The indices from the original regex may now overlap with or conflict with the overwritten region. If a font constructor call likeInter({ weight: '400' })appears on the same line as the import, MagicString will throw or silently produce corrupted output. The self-hosting section should either regex-match on the already-transformed code or ensure the regions don't overlap. -
Only the first
import { ... } from 'next/font/google'statement is handled. The regex uses.exec()without thegflag, so a file with two separate import statements fromnext/font/google(which is valid JS) will silently ignore the second one. This is an existing limitation that the old code also had, but given that this PR is rewriting the import handling, it's worth fixing now. -
The
vinext:nitro-compatplugin is unrelated to the font transform. This is a completely separate feature/fix that should be in its own PR. It also containsconsole.logdebug statements that should not ship to production.
Minor Issues
-
Font family derivation inconsistency between import rewrite and build-mode self-hosting. The import rewrite uses
f.imported.replace(/_/g, " ")(underscore-to-space only), but the build-mode self-hosting usesfontSpec.imported.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2")(underscore-to-space + PascalCase-to-space). Since Google Fonts uses underscores in their Next.js export names (not PascalCase), the PascalCase transform in the build path is dead code that could introduce bugs if someone used PascalCase names. These should be consistent. -
Missing test for
import { Inter as MyInter } from 'next/font/google'— the alias handling code is present but untested.
|
Review submitted. Here's a summary of the findings: Critical Issues (3)
Minor Issues (3)
|
… transform Closes cloudflare#200 Instead of a 1928-line generated font catalog (font-google.generated.ts) that re-exports every Google Font as a createFontLoader call, the vinext:google-fonts Vite plugin now rewrites next/font/google import statements at compile time. Address review feedback: - Fix MagicString index corruption: add overlap guard for self-hosting regex so font constructor calls that share a region with imported statements are safely skipped - Handle multiple import statements: use matchAll with gm flags to process all import { ... } from 'next/font/google' in a single file - Remove unrelated vinext:nitro-compat plugin (belongs in separate PR) - Fix font family derivation consistency: remove dead PascalCase-to-space transform in build-mode path to match dev-mode (underscore-to-space only) - Fix font-google guard: use shimsDir path check instead of id.includes() to avoid false positives on user files like font-google-helpers.ts - Fix fontCallRe regex: allow digits and lowercase after underscores to match fonts like Baloo_2 and M_PLUS_1p - Add utilityExports sync comment referencing font-google.ts barrel - Add tests: aliased imports, multiple import statements, digit-underscore font names
aae8f42 to
8c1d17f
Compare
|
@southpolesteve @james-elicx The fixes is pushed. Kindly review the changes. |
|
/bigbonk review |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review |
There was a problem hiding this comment.
Review: feat(font): replace generated font catalog with Vite import-rewriting transform
The core approach is sound — replacing a 1928-line generated catalog with a compile-time import rewrite is a clear maintenance and bundle-size win. The author has addressed the critical issues from the previous review (nitro-compat plugin removed, multiple import statements handled via gm flags, MagicString overlap detection added, alias test added, Baloo_2 test added). The implementation is well-structured with good separation between the import rewriting phase and the build-only self-hosting phase.
The remaining items are minor and don't block merge, but would be good to address.
packages/vinext/src/index.ts
Outdated
| // indices, so overlapping overwrites would throw. | ||
| const callStart = callMatch.index; | ||
| const callEnd = callStart + fullCallMatch.length; | ||
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; |
There was a problem hiding this comment.
Nit: variable shadowing — The destructured [s, e] shadows the outer const s = new MagicString(code) on line 2947. This doesn't cause a functional bug (the arrow function only uses s for index comparison, not as a MagicString), but it's a readability hazard — a future reader might think s here refers to the MagicString.
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; | |
| if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) continue; |
| // The ^ anchor with 'm' flag ensures we only match real import statements | ||
| // at the start of a line, not occurrences inside comments or strings. | ||
| // Uses the 'gm' flags to handle multiple import statements in one file. | ||
| const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm; |
There was a problem hiding this comment.
Minor: ^ anchor with m flag can match inside template literals. A multiline template string like:
const x = `
import { Inter } from 'next/font/google'
`;would be matched by this regex because the m flag makes ^ match at the start of any line, even inside template literals. In practice this is extremely unlikely to matter (who puts a next/font/google import inside a template string?), but worth noting if you want maximum robustness. The typical fix is to use an AST-based approach, but for a Vite transform this is a reasonable tradeoff.
packages/vinext/src/index.ts
Outdated
| // indices, so overlapping overwrites would throw. | ||
| const callStart = callMatch.index; | ||
| const callEnd = callStart + fullCallMatch.length; | ||
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; |
There was a problem hiding this comment.
Design note: self-hosting silently skipped for same-line import+call. When the import and constructor call are on the same line:
import { Inter } from 'next/font/google'; const inter = Inter({ weight: ['400'] });The import rewriting works correctly (Inter gets replaced with createFontLoader call), but the self-hosting CSS injection is skipped because the call overlaps with the overwritten import region. The font will still work in production but will load from CDN instead of being self-hosted.
This is a fine tradeoff since the single-line pattern is rare, but a comment documenting this intentional limitation would help future readers understand why the overlap check exists and what the user-facing consequence is.
| // Utility exports that are NOT font names — keep as regular imports. | ||
| // IMPORTANT: keep this set in sync with the non-default exports from | ||
| // packages/vinext/src/shims/font-google.ts (and its re-export barrel). | ||
| const utilityExports = new Set([ |
There was a problem hiding this comment.
Good call on the IMPORTANT: keep this set in sync comment. For even more robustness, consider deriving this set programmatically from the actual exports of font-google.ts (e.g., in a test) rather than relying on a manual sync. A test like:
it('utilityExports matches actual non-font exports', async () => {
const mod = await import('../packages/vinext/src/shims/font-google.js');
const actual = Object.keys(mod).filter(k => k !== 'default');
// Verify they match the hardcoded set in the plugin
});would catch drift automatically.
packages/vinext/src/index.ts
Outdated
| const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g; | ||
| // Skip our own shim files — they contain 'next/font/google' in comments | ||
| // and re-export statements that should not be rewritten. | ||
| if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null; |
There was a problem hiding this comment.
Using path.resolve(__dirname, 'shims') is more robust than the previous id.includes('font-google') guard that was flagged by the earlier review. Good fix.
| // Convert export-style names to proper font family names: | ||
| // - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono" | ||
| // - PascalCase to spaces: "RobotoMono" -> "Roboto Mono" | ||
| const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); |
There was a problem hiding this comment.
Good — the Proxy now handles both underscore-style names (Roboto_Mono) and PascalCase names (RobotoMono). This keeps the Proxy as a correct fallback for cases where the Vite plugin doesn't run (e.g., direct runtime access via mod.default.SomeFontName).
| plugin._fontCache.clear(); | ||
| }); | ||
|
|
||
| it("rewrites aliased font imports (import { Inter as MyFont })", async () => { |
There was a problem hiding this comment.
Good — this alias test was called out as missing in the previous review and has been added. Coverage for the import { X as Y } pattern is important since the rewriting code has separate imported vs local handling.
| expect(result.code).toContain("merged into first"); | ||
| }); | ||
|
|
||
| it("handles font names with digits after underscore (e.g. Baloo_2)", async () => { |
There was a problem hiding this comment.
Good — test for fonts with digits after underscore (Baloo_2) was also called out as missing and has been added. The updated fontCallRe regex on line 2998 of index.ts now matches [A-Za-z0-9] after underscore, which handles this correctly.
|
Review submitted successfully. Here's a summary of my findings: Review Summary for PR #262Overall assessment: The PR is in good shape. The core approach of replacing a 1928-line generated font catalog with a compile-time Vite import rewrite is a clear win. The author addressed all critical issues from the previous review. Key findings:
|
|
@james-elicx The PR is in good shape. Merge this PR. |
The general flow we're trying to follow at the moment is continually iterating until Bonk approves |
|
@copilot ask-bonk to review the pr and merge the pr. |
|
/bigbonk please re-review this pr |
There was a problem hiding this comment.
Review: feat(font): replace generated font catalog with Vite import-rewriting transform
The core design is a solid improvement — replacing ~1,900 lines of generated catalog with a compile-time import rewrite is a clear maintenance and bundle-size win. The previous review rounds have addressed the most critical issues (multiple import handling, MagicString overlap detection, nitro-compat removal, alias and digit-font tests). The implementation is well-structured overall.
I found one functional issue that should be fixed, and a few minor items worth addressing.
Issues
-
Variable shadowing in overlap check (minor bug risk) —
[s, e]shadows the outer MagicStrings. This was flagged in the previous bonk review but the suggestion hasn't been applied yet. -
Self-hosting silently degrades with no documentation — When an import and constructor call share the same line, self-hosting is silently skipped and the font falls back to CDN. This is a reasonable tradeoff but should have a code comment explaining the user-facing consequence.
-
utilityExportsset is a maintenance hazard — The hardcoded set must stay in sync withfont-google.tsexports. A test that validates this would prevent silent drift. -
Missing
typere-export in transformed output for multi-import merging — When type specifiers come from different import statements (e.g.,import { type FontConfig } from 'next/font/google'on one line andimport { Inter } from 'next/font/google'on another), the type specifiers from the second import are merged into the first import's replacement, but the second import is replaced with a comment. This works correctly. However, if a user only has type imports fromnext/font/google(no font names),fontImports.length === 0causes the transform to bail out and returnnull, which is correct since there's nothing to transform. Good.
Positive notes
- The
path.resolve(__dirname, 'shims')guard is robust — good fix from the previous review. /*#__PURE__*/annotations oncreateFontLoadercalls enable tree-shaking.- Test coverage for aliases, digit fonts, and multiple imports is thorough.
- The Proxy fallback in
font-google-base.tsnow handles both underscore and PascalCase names, which is correct.
packages/vinext/src/index.ts
Outdated
| // indices, so overlapping overwrites would throw. | ||
| const callStart = callMatch.index; | ||
| const callEnd = callStart + fullCallMatch.length; | ||
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; |
There was a problem hiding this comment.
Variable shadowing: [s, e] shadows the outer const s = new MagicString(code) from line 2947. This was flagged in the previous bonk review but hasn't been fixed. While it doesn't cause a functional bug today (the s in the arrow function is only used for index comparison), it's a readability hazard that could cause a real bug if someone later adds MagicString operations inside this callback.
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; | |
| if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) continue; |
packages/vinext/src/index.ts
Outdated
| // Skip calls that overlap with an already-overwritten import region | ||
| // (e.g. import and call on the same line). MagicString uses original | ||
| // indices, so overlapping overwrites would throw. | ||
| const callStart = callMatch.index; | ||
| const callEnd = callStart + fullCallMatch.length; | ||
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; |
There was a problem hiding this comment.
Silent self-hosting skip deserves a comment: When the import and constructor call are on the same line (e.g., import { Inter } from 'next/font/google'; const inter = Inter({...});), the overlap check correctly prevents MagicString corruption by skipping the self-hosting injection. But the user-facing consequence — the font loads from CDN instead of being self-hosted — is non-obvious.
Consider adding a brief comment documenting this limitation:
| // Skip calls that overlap with an already-overwritten import region | |
| // (e.g. import and call on the same line). MagicString uses original | |
| // indices, so overlapping overwrites would throw. | |
| const callStart = callMatch.index; | |
| const callEnd = callStart + fullCallMatch.length; | |
| if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue; | |
| // Skip calls that overlap with an already-overwritten import region | |
| // (e.g. import and call on the same line). MagicString uses original | |
| // indices, so overlapping overwrites would throw. | |
| // NOTE: Skipped fonts will still work but load from CDN instead of being self-hosted. | |
| const callStart = callMatch.index; | |
| const callEnd = callStart + fullCallMatch.length; |
| // Utility exports that are NOT font names — keep as regular imports. | ||
| // IMPORTANT: keep this set in sync with the non-default exports from | ||
| // packages/vinext/src/shims/font-google.ts (and its re-export barrel). | ||
| const utilityExports = new Set([ | ||
| "buildGoogleFontsUrl", "getSSRFontLinks", "getSSRFontStyles", | ||
| "getSSRFontPreloads", "createFontLoader", | ||
| ]); |
There was a problem hiding this comment.
Sync hazard: This hardcoded set must stay in sync with the actual exports from font-google.ts. The comment is helpful, but a test would be more reliable. Consider adding something like:
it('utilityExports matches actual non-font exports from font-google.ts', async () => {
const mod = await import('../packages/vinext/src/shims/font-google.js');
const expectedUtilities = Object.keys(mod).filter(k => k !== 'default' && typeof mod[k] === 'function');
// compare against the hardcoded set in the plugin
});This would catch drift automatically rather than relying on the comment.
| // The ^ anchor with 'm' flag ensures we only match real import statements | ||
| // at the start of a line, not occurrences inside comments or strings. | ||
| // Uses the 'gm' flags to handle multiple import statements in one file. | ||
| const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm; |
There was a problem hiding this comment.
Minor: The ^ anchor with m flag matches at the start of any line, including inside template literals. For example:
const x = `
import { Inter } from 'next/font/google'
`;would be matched. In practice this is extremely unlikely (who puts a next/font/google import inside a template string?), so this is an acceptable tradeoff vs. a full AST parse. Just noting for awareness.
|
Review submitted successfully. Here's a summary of my findings: Review Summary for PR #262Overall: The approach is a clear win — replacing 1,928 lines of generated catalog with a compile-time import rewrite. The previous review rounds have addressed the critical issues well. The implementation is well-structured. Key findings:
|
…ent, utilityExports sync test
|
Addressed all 3 items from the latest bonk review:
All 58 font-google tests pass. |
|
/bigbonk re-review the pr please |
There was a problem hiding this comment.
Review: feat(font): replace generated font catalog with Vite import-rewriting transform
The core approach is a clear win — replacing ~1,900 lines of generated catalog with a compile-time import rewrite eliminates maintenance burden and makes any Google Font work automatically. The implementation is well-structured with good separation between import rewriting and build-only self-hosting. Previous review rounds have addressed the major issues (multiple imports, MagicString overlap detection, alias support, digit-containing font names, variable shadowing, sync test).
I have a few remaining observations, ranging from a potential correctness issue to minor suggestions.
Summary
-
path.sepvs/cross-platform inconsistency — The shim guard at line 2900 usespath.sepwhile the existing_shimsDirconstant at line 370 uses a hardcoded/. These should be consistent, and ideally the new code should reuse_shimsDirrather than recomputing the path. -
typespecifiers are always re-emitted even if no font imports exist — If a user hasimport { type FontConfig } from 'next/font/google'alongside actual font names, the type specifiers are correctly preserved. But the current logic also addscreateFontLoader as __vinext_clfeven when all specifiers ended up intypeSpecifiers/utilityImports. The earlyfontImports.length === 0bail-out handles this correctly — good. -
Redundant
path.resolvecomputation — Thepath.resolve(__dirname, 'shims')is computed on everytransformcall. This could be hoisted toconfigResolvedor reuse the existing module-level_shimsDir. -
The
.d.tsstill exports all ~1,900 font names — This is correct for IDE autocomplete, but it meansgenerate-google-fonts.jsstill needs to be run periodically. Worth a brief note in the PR description that the generated.d.tsis retained intentionally for DX. -
Good test coverage — The alias test, digit-font test, multiple-import test, and utilityExports sync test are all solid additions.
packages/vinext/src/index.ts
Outdated
| const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g; | ||
| // Skip our own shim files — they contain 'next/font/google' in comments | ||
| // and re-export statements that should not be rewritten. | ||
| if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null; |
There was a problem hiding this comment.
Nit: There's already a module-level constant _shimsDir at line 370 that computes the same path:
const _shimsDir = path.resolve(__dirname, "shims") + "/";This line recomputes it on every transform call and uses path.sep instead of the hardcoded / that _shimsDir uses. On Windows, path.sep is \ while the existing constant uses /, so the behavior would differ.
Consider reusing _shimsDir for consistency and to avoid redundant computation:
| if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null; | |
| if (id.startsWith(_shimsDir)) return null; |
| // The ^ anchor with 'm' flag ensures we only match real import statements | ||
| // at the start of a line, not occurrences inside comments or strings. | ||
| // Uses the 'gm' flags to handle multiple import statements in one file. | ||
| const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm; |
There was a problem hiding this comment.
The ^ anchor with m flag means this will match import at the start of any line, including inside multiline template literals or multiline comments. This has been noted in previous reviews as an acceptable tradeoff, and I agree — an AST-based approach would be overkill here.
However, there's one practical edge case worth considering: a multiline block comment like:
/*
import { Inter } from 'next/font/google'
*/This would be matched and rewritten, breaking the comment. Consider whether adding a simple pre-check (e.g., stripping block comments first, or verifying the match isn't inside /* */) is worth the complexity. It's a judgment call — this pattern is unlikely but not impossible (e.g., someone commenting out an import during debugging).
packages/vinext/src/index.ts
Outdated
| // Merge specifiers from all matched imports | ||
| const rawSpecifiers: string[] = []; | ||
| for (const m of importMatches) { | ||
| rawSpecifiers.push(...m[1].split(",").map((s) => s.trim()).filter(Boolean)); |
There was a problem hiding this comment.
Minor: The lambda parameter (s) shadows the outer MagicString variable s declared at line 2947. While not a bug (since s here is just the string from split), it's a readability hazard in a function that already had a variable shadowing issue flagged in previous reviews.
Consider renaming to avoid confusion:
| rawSpecifiers.push(...m[1].split(",").map((s) => s.trim()).filter(Boolean)); | |
| rawSpecifiers.push(...m[1].split(",").map((spec) => spec.trim()).filter(Boolean)); |
| const end = start + m[0].length; | ||
| overwrittenRanges.push([start, end]); | ||
| if (i === 0) { | ||
| s.overwrite(start, end, newImport + "\n" + fontDecls); |
There was a problem hiding this comment.
When the first import is replaced with newImport + "\n" + fontDecls, the replacement text could be significantly longer or shorter than the original import line. This is fine for MagicString (it handles variable-length replacements), but it means the source map will map the entire block of const declarations back to a single original import line.
This is acceptable for a Vite transform — just noting it for awareness since debugging through source maps in this region may show unexpected line numbers.
| const utilityExports = new Set([ | ||
| "buildGoogleFontsUrl", "getSSRFontLinks", "getSSRFontStyles", | ||
| "getSSRFontPreloads", "createFontLoader", | ||
| ]); |
There was a problem hiding this comment.
Good that this is validated by the test at font-google.test.ts:672. One thing to watch: the getSSRFontPreloads export is in this set but isn't exercised in many of the plugin tests. If it were ever removed from font-google.ts, the sync test would catch it, but it's worth making sure the test at line 672 is robust (which it is — it compares sorted arrays).
| // Convert export-style names to proper font family names: | ||
| // - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono" | ||
| // - PascalCase to spaces: "RobotoMono" -> "Roboto Mono" | ||
| const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); |
There was a problem hiding this comment.
Good improvement — the Proxy now handles both Roboto_Mono (underscore) and RobotoMono (PascalCase) naming conventions. This makes the fallback Proxy consistent with how the Vite plugin derives family names.
Note that the order matters here: replace(/_/g, " ") runs first, then replace(/([a-z])([A-Z])/g, ...). For a name like Roboto_Mono, the underscore replacement produces "Roboto Mono" and the PascalCase replacement is a no-op (since there's a space between o and M). For RobotoMono, the underscore replacement is a no-op and the PascalCase replacement produces "Roboto Mono". This is correct.
| // ── utilityExports sync validation ──────────────────────────── | ||
|
|
||
| describe("utilityExports sync", () => { | ||
| it("hardcoded utilityExports matches actual non-font exports from font-google.ts", async () => { |
There was a problem hiding this comment.
This is a valuable test — it catches drift between the hardcoded utilityExports set in the plugin and the actual exports from font-google.ts. Well done adding this after the review feedback.
One edge case: if someone adds a non-function export to font-google.ts (e.g., a constant or type re-export), this test would catch it since it checks all non-default keys. That's the right behavior.
|
Review submitted successfully. Here's a summary of what I found: Overall assessment: The PR is in good shape. Replacing ~1,900 lines of generated font catalog with a compile-time Vite import rewrite is a clear maintenance and bundle-size win. The author has addressed all critical issues from previous review rounds. Key findings:
|
Address bonk review: - Reuse module-level _shimsDir constant instead of recomputing path.resolve(__dirname, 'shims') + path.sep on every transform call. Also fixes cross-platform inconsistency (path.sep vs hardcoded /). - Rename (s) to (spec) in .map() callback to avoid shadowing the outer MagicString variable s.
|
Addressed the latest bonk review items:
All 58 font-google tests pass. Typecheck clean. |
|
/bigbonk re-review please |
There was a problem hiding this comment.
Review
The core idea is a clear win — replacing ~1,900 lines of generated catalog with a compile-time import rewrite is a significant maintenance and bundle-size improvement. The implementation is well-structured with clean separation between import rewriting (dev+build) and self-hosting (build-only).
The author has iterated well through previous review rounds, addressing the critical issues (multiple imports via gm flags, MagicString overlap detection, nitro-compat removal, alias support, variable shadowing fix, _shimsDir reuse, utilityExports sync test).
I found one functional concern with the self-hosting regex and a few minor items below.
| // Match font constructor calls: Inter({ weight: ..., subsets: ... }) | ||
| // We look for PascalCase/Name_Name/Name_2 identifiers followed by ({...}). | ||
| // The segment after _ can start with any alphanumeric (e.g. Baloo_2, M_PLUS_1p). | ||
| const fontCallRe = /\b([A-Z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*)\s*\(\s*(\{[^}]*\})\s*\)/g; |
There was a problem hiding this comment.
The options-matching group \{[^}]*\} cannot match nested objects because [^}] stops at the first }. For example:
const inter = Inter({ weight: '400', axes: { wght: 400 } });would not be matched by fontCallRe, so self-hosting would silently skip it (font falls back to CDN).
I realize this is a pre-existing limitation (the old code had the same regex), but since this PR is the natural place to improve it — and parseStaticObjectLiteral already handles nested objects correctly (there's even a test for it at line 611) — it's worth noting.
A simple brace-counting approach would fix this:
// Instead of \{[^}]*\}, match balanced braces
function extractBalancedBraces(code: string, openIndex: number): string | null {
let depth = 0;
for (let i = openIndex; i < code.length; i++) {
if (code[i] === '{') depth++;
else if (code[i] === '}') { depth--; if (depth === 0) return code.slice(openIndex, i + 1); }
}
return null;
}Not blocking — just calling it out as a follow-up opportunity.
| importMatch[1].split(",").map((s) => s.trim()).filter(Boolean), | ||
| ); | ||
| // Nothing to transform if no font-name imports | ||
| if (fontImports.length === 0) return null; |
There was a problem hiding this comment.
If a file has import { buildGoogleFontsUrl } from 'next/font/google' (only utility imports, no font names), the transform returns null and the import passes through unchanged. This is correct because font-google.ts already exports buildGoogleFontsUrl and the import will resolve normally.
However, if a file has import { type FontLoader } from 'next/font/google' (only type imports), fontImports is empty and we also return null. This is also correct since TypeScript will have already erased type-only imports before the Vite transform runs.
Just confirming I traced through both paths and they're handled correctly — nice.
| const newSpecifiers = [ | ||
| ...typeSpecifiers, | ||
| ...utilityImports, | ||
| "createFontLoader as __vinext_clf", |
There was a problem hiding this comment.
Minor: createFontLoader as __vinext_clf is always added to newSpecifiers, even when it's already present in utilityImports (because the user explicitly imported createFontLoader). This would produce:
import { createFontLoader, createFontLoader as __vinext_clf } from 'next/font/google';This is valid JS (duplicate specifiers are allowed), but it's slightly wasteful. Consider skipping the __vinext_clf alias when createFontLoader is already in utilityImports:
| "createFontLoader as __vinext_clf", | |
| "createFontLoader as __vinext_clf", |
Alternatively, if createFontLoader is in utilityImports, you could remove it from there and only add the aliased version. Not blocking — the current output works.
| const fontCallRe = /\b([A-Z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*)\s*\(\s*(\{[^}]*\})\s*\)/g; | ||
|
|
||
| let callMatch; | ||
| while ((callMatch = fontCallRe.exec(code)) !== null) { |
There was a problem hiding this comment.
The self-hosting regex runs on the original code while the MagicString s has already had import regions overwritten. This is correct because MagicString tracks overwrites by original-string indices, and the overlap check on line 3011 prevents double-overwrites. The s.overwrite(matchStart, matchEnd, ...) on line 3083 uses original-code indices, which is what MagicString expects.
This is a subtle but important correctness property — worth a one-line comment noting that fontCallRe intentionally runs on the original code (not s.toString()) because MagicString uses original indices.
| // The ^ anchor with 'm' flag ensures we only match real import statements | ||
| // at the start of a line, not occurrences inside comments or strings. | ||
| // Uses the 'gm' flags to handle multiple import statements in one file. | ||
| const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm; |
There was a problem hiding this comment.
The [ \t]* prefix allows optional indentation, but it also means the regex won't match an import preceded by, say, a semicolon on the same line:
;import { Inter } from 'next/font/google'This is fine — that's not valid formatting. But there's one real-world edge case: a file with a BOM (byte order mark) at the very beginning. The BOM (\uFEFF) would appear before the first import and prevent the ^ anchor from matching on line 1 (since ^ with m flag matches start-of-line, and the BOM is a character before import).
This is extremely unlikely in practice (modern toolchains strip BOMs), so not blocking. Just noting for completeness.
| // ── utilityExports sync validation ──────────────────────────── | ||
|
|
||
| describe("utilityExports sync", () => { | ||
| it("hardcoded utilityExports matches actual non-font exports from font-google.ts", async () => { |
There was a problem hiding this comment.
This sync test is a great addition — it catches drift between the hardcoded utilityExports in the plugin and the actual exports from font-google.ts.
One subtle thing: the test imports from font-google.js and checks Object.keys(mod).filter(k => k !== 'default'). Since font-google.ts re-exports from font-google-base.ts, the keys here reflect the runtime exports. If someone added a type-only export to font-google.ts, it wouldn't show up in Object.keys(mod) and wouldn't trigger a test failure — but it also wouldn't need to be in utilityExports since the plugin only deals with runtime values. So this is correct.
| // Convert export-style names to proper font family names: | ||
| // - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono" | ||
| // - PascalCase to spaces: "RobotoMono" -> "Roboto Mono" | ||
| const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); |
There was a problem hiding this comment.
The Proxy now handles both underscore (Roboto_Mono) and PascalCase (RobotoMono) naming. The order of replacements is correct: underscore-to-space first, then PascalCase-to-space. For Roboto_Mono, the first replacement produces "Roboto Mono" and the second is a no-op.
This makes the Proxy a correct fallback for any code path that bypasses the Vite plugin (e.g., direct mod.default.SomeFontName access at runtime).
| const end = start + m[0].length; | ||
| overwrittenRanges.push([start, end]); | ||
| if (i === 0) { | ||
| s.overwrite(start, end, newImport + "\n" + fontDecls); |
There was a problem hiding this comment.
When the first import is replaced with newImport + "\n" + fontDecls, the replacement text could be significantly longer than the original import line. This means the source map will map the entire block of const declarations back to a single original import line.
This is an acceptable tradeoff for a Vite transform, but it means that if a user sets a breakpoint on one of the generated const declarations, the debugger may behave unexpectedly. Worth knowing but not worth fixing — the hires: "boundary" option on line 3089 already provides the best mapping MagicString can offer for variable-length replacements.
|
Review submitted successfully. Here's a summary of what I found: Overall assessment: The PR is in good shape. Replacing ~1,900 lines of generated font catalog with a compile-time Vite import rewrite is a clear maintenance and bundle-size win. The author has addressed all critical issues from previous review rounds well. Key findings:
|
|
Addressed the latest bonk review items:
All 59 font-google tests pass. Typecheck clean. |
|
@james-elicx Review the PR |
Summary
Closes #200
Instead of a 1928-line generated font catalog (
font-google.generated.ts) that re-exports every Google Font as acreateFontLoadercall, thevinext:google-fontsVite plugin now rewritesnext/font/googleimport statements at compile time.Before
After
The plugin rewrites imports at compile time across all Vite environments (RSC, SSR, client):
Self-hosted font fetching during production builds continues to work as before.
Changes
packages/vinext/src/shims/font-google.generated.ts(1928 lines / ~70 KB)font-google.tsto exportcreateFontLoaderinstead of re-exporting the catalogcreateFontLoaderexport tonext-shims-font-google.generated.d.tsfor IDE autocompletevinext:google-fontsVite plugin to perform import-time transforms in dev + buildfont-google-base.tsto handle underscore-style names (Roboto_Mono→'Roboto Mono') for users who skip the Vite plugingenerate-google-fonts.jsto only generate.d.ts(no longer generates.tscatalog)createFontLoaderAPI instead of named exportsTesting
font-google.test.tstests passshims.test.tspassentry-templates.test.tstests pass